跳转至

RFZ 字体解包权威规范

本文档由直接复核 大四应用.exe 的反编译产物得出,目的是给出可一次性正确解包 RFZ 字体的完整、精确算法。 它修正了此前 font/yb_lzw_full.py 等独立解码器"前 5KB 正确、5000+ 码字后分叉"的核心缺陷。


0. 解包流水线总览

RFZ 文件
  └─[1] 跳过 4 字节头部 (magic "YS" + version 2 + flags)
        └─[2] 从偏移 4 开始的字节流 = LZW 压缩流 (含所谓"8 保留字节")
              └─[3] yabukita StreamAdapterLZW 解压
                    └─ ChunkProcessorBinary 序列化流
                          └─ ruhuna::Database (RHFONTDB) 字体对象
                                ├─ 元数据 (point/glyph_cnt/tex_w...)
                                ├─ Glyph 数组 (逐字符度量, 48B)
                                └─ TextureResource (ARGB4444 16bpp DDS 图集)

唯一的"难点"是第 [3] 步的 LZW 解压。本文档重点解决它。


1. 文件头 (验证函数: YbStreamAdapter_ReadWriteHeader @ 0xEA20F0)

Offset  Size  Value                Description
0x00    2     "YS" (0x59 0x53)     Magic, LE u16 == 21337
0x02    1     0x02                 Version (必须 == 2)
0x03    1     0x00                 Flags
0x04    ...   LZW 压缩流            (一直到文件尾)

校验逻辑 (0xEA216A 起):

ReadBytes(&hdr, 4);                       // 仅消费 4 字节
if ((u16)hdr != 21337 || BYTE2(hdr) != 2) // "YS" 且 version==2
    error;

⚠️ 关键: 头部只消费 4 字节。此前文档称的"8 个保留字节 0x2C9048458008020A" 并不是独立字段, 而是 LZW 比特流的前 8 字节(恰好是固定常数, 编码了 RHFONTDB 类名前缀)。 解码器输入流必须从偏移 4开始, 不要再跳过这 8 字节。


2. LZW 解码器 (核心)

项目 函数 地址
初始化 YbLzwDecoder_Init 0xEA22C0
解压主循环 YbLzwDecoder_Fill 0xEA1730
底层字节读取 YbStream_ReadBytes 0xEA03D0

天境版 (高三应用.exe) 对应函数为 sub_AEB6A0 / sub_AEAD60, 算法完全相同。

2.1 初始化常量 (复核自 0xEA22C0)

dict        = malloc(0x8000);        // 4096 条目 × 8 字节
out_base    = malloc(0x2000);        // 8KB
out_ptr     = out_base;
window      = out_base + 4096;       // 4KB 反转缓冲(链解码用)
bit_buffer  = 0;
bits_buffered = 0;                   // this+72
max_entries  = 4096;                 // this+96
init_code_bits = 9;                  // this+64
code_bits    = 9;                    // this+68  (= init_code_bits)
init_tokens  = 256;                  // this+100
first_byte   = 0;                    // this+80
first_call   = 1;                    // this+56
// 字典初始化: dict[i] = { byte = (i<256 ? i : 0), next = 0 }  for i in 0..4095
// next_code 在首码字处被设为 init_tokens(256)

2.2 字典条目结构 (8 字节)

struct YbLzwEntry {
    uint8_t  last_byte;   // +0: 该码字序列的【最后一个】字节
    uint8_t  _pad[3];     // +1
    uint32_t next_ptr;    // +4: 指向前缀条目的【字节指针】(&dict[prefix]); 0 = 链尾(根)
};
  • 注意 next_ptr绝对字节指针 dict + 8*prefix_code, 不是索引。
  • 沿 next_ptr 走到 next_ptr==0 即根条目, 其 last_byte 是整串的第一个字节(根字节)。

2.3 链解码 (输出一个码字代表的字节串)

entry = &dict[code]
n = 4096
while (entry->next_ptr != 0):       # 从尾向根遍历
    window[--n] = entry->last_byte  # 反向写入窗口
    entry = entry->next_ptr
root_byte = entry->last_byte        # 根 = 串首字节
输出: root_byte, 然后 window[n..4095] (正向)
first_byte = root_byte              # 记录串首字节, 供下一轮 KwKwK 使用

2.4 MSB-first 位读取 (复核自 0xEA1995 稳态分支)

need_bytes = ceil((code_bits - bits_buffered) / 8)
for k in 0..need_bytes-1:                # 大端累积
    bit_buffer = (bit_buffer << 8) | next_input_byte()
shift = 8*need_bytes - code_bits + bits_buffered
code  = bit_buffer >> shift               # 取顶部 code_bits 位
bit_buffer &= (1 << shift) - 1            # 保留低位
bits_buffered += 8*need_bytes - code_bits
bits_buffered = 当前 bit_buffer 中仍有效的位数。

2.5 主循环逻辑

# 首码字 (first_call):
code = read(code_bits)
next_code = 256
输出 chain(code); prev_code = code; first_byte = root(code)
first_call = 0

# 后续码字:
loop:
    code = read(code_bits)
    if code > next_code: 返回(流损坏)
    if code < next_code:                 # 普通: 码字已存在
        输出 chain(code)
        fb = root(code)
    else:                                # code == next_code, KwKwK 情形
        输出 chain(prev_code)
        输出 first_byte                  # 追加上一串的首字节
        fb = root(prev_code)

    # —— 加表 / 重置判定 (见 2.6) ——
    if next_code != max_entries - 2:     # 即 next_code != 4094
        dict[next_code] = { last_byte: fb, next_ptr: &dict[prev_code] }
        old = next_code
        next_code += 1
        if old + 2 == (1 << code_bits):  # 早切码宽增长
            code_bits += 1
    else:
        DICT_RESET()                      # 见 2.6, 且【本码字不加表】
        # 重置后内联读取下一码字作为"重置后首码字"(同样不加表), 然后继续

    first_byte = fb
    prev_code = code

2.6 ★ 字典重置 (此前所有独立解码器遗漏的关键点) ★

复核自 0xEA1AC1 起的分支。当 next_code == max_entries - 2(即 4094)时:

# 不为当前码字加表, 而是:
for i in init_tokens .. max_entries-1:   # 256..4095
    dict[i].next_ptr = 0                 # 清空(仅清 next 指针)
next_code = init_tokens                  # = 256
code_bits = init_code_bits               # = 9
# 立即内联读取下一码字, 当作"重置后首码字":
#   - 按新的 9 位读取
#   - 正常输出其 chain (或 KwKwK)
#   - 但【不加表】(直接进入下一轮)
#   - prev_code = 该码字; first_byte = root(该码字)

要点: 1. 触发阈值是 4094 (max_entries-2), 不是 4096。 条目 4094/4095 永不被写入。 2. 重置时当前码字不加表。 3. 重置后的第一个码字也不加表(行为等同最初的首码字)。 4. 只清 next_ptr, last_byte 保留(无所谓, 因为 0..255 仍是 identity, 256+ 会被重新覆盖)。

这就是 yb_lzw_full.py 在约 3838 个新条目(256→4094)后解码分叉的根因: 该实现从不重置, 只在 next_code>=4096 时停止加表 → 与编码器字典彻底错位, 表现为日志里 "code=1925 vs next_code=1498" 类的失配。 修复方法: 完全按 2.5/2.6 实现, 尤其是 4094 触发的重置与"重置后首码字不加表"。

2.6b ★ null 哨兵必须区分"根"与"前缀码 0" ★

汇编中 next_ptr绝对字节指针 dict_base + 8*prefix_code。当 prefix_code == 0 时 该指针 = dict_base ≠ 0, 因此码 0 作为前缀时不是链尾, 对应字节必须照常输出。 只有初始 identity 条目(0..255)和被重置清空的条目其 next_ptr 才是真正的 0(链尾)。

yb_lzw_full.py 用整数 0 同时表示"identity 根"和"前缀码 0", 导致每当某条目的前缀链 经过码 0 时少输出一个 0x00 字节。这在 offset 0x24("ruhuna::Database\0" 之后)就已触发: 正确输出是 ...Database 00 00 1b..., 错误输出是 ...Database 00 1b...。 实现时应用 -1/None 作为链尾哨兵, 不要用 0。

2.7 码宽增长时机 (易错点)

游戏用的是 GIF 式早切(early change):

加表后, 当 (旧 next_code + 2) == (1 << code_bits) 时 code_bits++。
等价: 当新的 next_code 达到 (2^code_bits - 1) 时, 下一码字按 code_bits+1 位读取。
yb_lzw_full.py 用的是 next_code >= (1<<code_bits)(晚一个码字), 这本身也是 bug。


3. 解压产物: ChunkProcessorBinary 序列化流 (2026-06-10 全量复核, 6 个字号文件全部验证)

⚠️ 此前 "tag = field_id + 0x2711" 的标签模型是错误的。实际格式是 自描述 schema + 实例数据, 无逐字段标签。下文为逐字节验证后的正确模型。

3.1 YABX 文件头 (16 字节)

Offset Size Value             Description
0x00   4    "YABX"            magic
0x04   4    1                 version (u32)
0x08   4    filesize-16       payload 字节数 (u32, = 解压流总长 - 16)
0x0C   4    hash              payload 校验/哈希 (u32)

3.2 Schema 段 (类型自描述)

紧随 16 字节头, 依次声明各类: ruhuna::Object / ruhuna::Database / ruhuna::Glyph / ruhuna::TextureResource。每个类: <类名(带长度前缀)> 后跟字段列表, 每字段为

01 <type> 00 <u8 namelen> <name\0>
类定义以 00 00 .. 收尾。字段类型码 (已验证):

type 含义 实例编码
0x00 字符串/复杂对象/数组 TLV: <u32 size><u16 strlen+1><chars\0>
0x01 id (字符串语义) 同 0x00 的 TLV
0x02 u16 内联 2 字节, 无长度前缀
0x04 u32 内联 4 字节
0x18 blob (纹理数据) <u32 size>...

ruhuna::Database 字段顺序 (复核自解压流 schema 区): id, platform, library, name, comment (字符串) → flags(u32), point(u16), max_ascent, max_descent, max_glyph_w, max_glyph_htex_page(u32), tex_w(u16), tex_h, tex_last_h, glyph_margin, glyph_cntglyph(数组), texture

ruhuna::Glyph 字段顺序: code(type01), cell_inc_x(u16), cell_inc_y, page, origin_x, origin_y, box_x1, box_y1, box_x2, box_y2, kerning_info_cnt(u16), kerning_info(数组)

3.3 实例数据段 (Database 根对象)

Schema 后即根 Database 实例, 按 §3.2 字段顺序序列化。锚点: id 字段恒为 0b 00 00 00 09 00 "RHFONTDB\0" (id 值即 DB magic, 6 个文件相同)。

14pt 实测元数据: point=10, max_ascent=13, max_descent=3, max_glyph_w=17, max_glyph_h=16, tex_page=2, tex_w=2048, tex_h=2048, tex_last_h=1024, glyph_margin=3, glyph_cnt=21720

point ≈ pt × 0.75 (96→72 DPI): 14pt→10, 18pt→14, 24pt→18, 32pt→24, 60pt→45, 240pt→180。即字形单元的实际像素 em。

glyph 字段体: <u32 fieldsize><u32 count><count × u16 localID> (localID = 索引+0x2712)。 紧随其后是 字形对象定义区 (10 字节对象表头 06 00 00 00 01 00 00 00 <u16> 之后)。

3.4 字形对象定义 (每条固定 6 + body_size 字节)

<u16 marker=3> <u32 body_size=30> <body>
body = code(u16) + 10×u16 字段 + kerning_info
     = code, cell_inc_x, cell_inc_y, page, origin_x, origin_y,
       box_x1, box_y1, box_x2, box_y2, kerning_info_cnt,
       <u32 kerning_size=4><u32=0>   (本字体所有 kerning_info_cnt 均为 0)
  • 6 个文件全部解析: 每条 marker 恒=3, body_size 恒=30, code 单调递增。
  • 字形数组仅含存在的字形 (非全 code 范围); code→glyph 映射在游戏加载时建表。
  • 空格 (code 32): cell_inc_x=4, 其余 page/origin/box 全 0xffff (未渲染哨兵)。
  • 更正 (2026-06-14): box_x1/y1/x2/y2 是 DDS 图集中的像素矩形坐标, 不是 此前所称的 "PFR 源单元坐标"。三重证据 (见 rfz_glyph_coords_analysis.md): (1) 数据: 连续字形 box 单调右移、box_x2 max=2047≈tex_w、165 次在 2048 处换行、按 page 翻页; (2) 像素: 解码 DDS 在 box 区域确有字形墨迹, 行间隙为纯透明; (3) 代码: BitmapFont_BuildGlyphVertices(0xF07900) 四边形宽高=box_x2-box_x1/box_y2-box_y1, UV=box 像素/2048 (加载时预算为 +92..+120 float)。 字段语义 (→ bmfont 对应): cell_inc_x=advance(xadvance), cell_inc_y=竖排前进, page=纹理页(page), origin_x/y=笔位 bearing(xoffset/yoffset), box_x1/y1=图集左上(x,y), box_x2/y2=右下(x+w,y+h)。

4. 内嵌纹理 (★ 修正: ARGB4444 非 DXT5 ★)

⚠️ 此前文档称 "DXT5 DDS" 是错误的。逐字节复核 DDS 头证实为 ARGB4444 16bpp 未压缩

每张纹理是标准 DDS ("DDS " magic + 124 字节 dwSize 头), 像素格式:

ddspf.dwFlags     = 0x41   (DDPF_RGB | DDPF_ALPHAPIXELS)
ddspf.dwFourCC    = 0      (无 FourCC → 未压缩)
ddspf.dwRGBBitCount = 16
R mask = 0x0F00   G mask = 0x00F0   B mask = 0x000F   A mask = 0xF000   (ARGB4444)
dwPitchOrLinearSize = W × H × 2
  • 张数 = 元数据 tex_page; 末张高度 = tex_last_h。 实测: 14pt→2 张 (2048×2048 + 2048×1024), 60pt→20 张 (末张 2048×512), 与 tex_page 完全一致。
  • 提取: 扫描 "DDS " (校验其后 dwSize==124), 每张长度 = 128 + dwPitchOrLinearSize

4.0 ★ 纹理段实为内嵌 svo 容器 (2026-06-14 实测补充) ★

§3.2 把纹理段简记为 "TextureResource schema+实例", 但逐字节复核 decompressed.bin 表明它是一个完整内嵌的 svo 容器 (与本项目 SVO 模型一致, 字体 svo 只有 DDS 无几何)。 14pt 实测偏移:

0x00000  YABX                          顶层 ChunkProcessor 容器
0x0022d  TextureResource schema
0x0025b  RHFONTDB                      Database 实例 (元数据+字形)
0xc9b0d  AVTS                          ← svo chunk 目录 (stride 0x400)
0xc9b8d  __HmfToSvo__RFO_..._0000.dds  svo chunk 文件名 (每条间隔 0x400)
0xca78d  YABX                          ← svo 内层容器
0xcaf8d  DDS  (page0, ARGB4444)
0x8cb00d DDS  (page1)
0xccb0a0 EOF
  • AVTS = svo 块目录魔数, stride 0x400, 条目含 __HmfToSvo__<font>_<page4位>.dds 文件名, 印证资产管线曾用 HMF→SVO 工具封装纹理。HmfToSvo 在流中出现 2 次 (chunk 名 + 资源名)。
  • 现有 rfz_pack.py 把整个纹理段 (svo 目录+内层 YABX+TextureResource+DDS 头) 当作模板段 F 原样复制, 仅替换 DDS 像素 (段 G)。因此换字体名/页数需逆向并重生成此 svo 容器写入器 (见 rfz_pack_spec.md §6/§9 的"模板依赖"取舍)。

★ 此前 live_extracted/atlas_*.dds 命名暗示 DXT5 实为误标; 实际是 ARGB4444。

4.1 关于 "0x300000 处 0FFF 游程" (修正)

§7b 旧称该处为 "字符索引表 65478×u16 哨兵" 是误读: 0x300000 落在第一张 2048×2048 纹理的像素区内, 0x0FFF = ARGB4444 透明白 (A=0,RGB=F), 是图集空白区, 不是字符索引表。文件中没有独立字符索引表; code→glyph 由每字形的 code 字段 在加载时建表。

IDA 复核 sub_F157A0 (RHFONTDB 加载时建表) 证实: start = glyph[0].code (this+144), end = glyph[count-1].code (this+146), table = malloc(2×(end-start+1)) (this+140, 先 memset 0), 然后 for i in 0..count-1: table[glyph[i].code - start] = i。 即索引表是运行时由各字形 code 字段动态构建的, 不在 RFZ 文件内。 (runtime glyph 对象的 code 字段在 +12; 文件内 code 是每字形对象的首字段。)


4b. 离线解包脚本 (交付物)

font/rfz_unpack.py — 单文件解包器, 实现本规范全部内容:

python rfz_unpack.py <input.rfz> [out_dir]
  → decompressed.bin   解压流
  → metadata.json      §3.3 元数据
  → glyphs.csv         §3.4 逐字形 (code + cell_inc/origin/box/kerning_cnt)
  → page0.dds, page1.dds, ...   §4 ARGB4444 图集
6 个字号文件全部解包通过 (字形数、纹理张数与元数据完全自洽; LZW 零非法码)。


5. 解包实操路线

路线 A — 离线纯解码 (本规范修复后可行)

按 §1–§2 实现解码器(尤其 §2.6 重置), 直接把 RFZ 解成 ChunkProcessor 流, 再按 §3 解析。 无需运行游戏, 适合批量处理 6 个字号文件。

路线 B — 动态抓取 (已验证可用, 见 progress_summary.md §5)

IDA attach 大四应用.exe → 在 YbLzwDecoder_Fill(运行时 0x16AB2FE 返回处)抓输出, 或直接在 RHFONTDB 解析后抓 rhfontdb_*.bin。纹理在 GPU 上传后内存即释放, 需在上传前抓 DDS。 live_extracted/ 中的产物即来自此路线。

路线 A 是离线复现的正解; 路线 B 用于交叉验证 A 的输出是否逐字节一致。


6. 关键地址速查 (大四应用.exe, imagebase 0x400000)

函数(已在 IDB 重命名) 地址 说明
YbLzwDecoder_Fill 0xEA1730 LZW 解压主循环(含重置), 已加详细注释
YbLzwDecoder_Init 0xEA22C0 字典/状态初始化
YbStream_ReadBytes 0xEA03D0 底层字节读取(带 1 字节回退)
YbStreamAdapter_ReadWriteHeader 0xEA20F0 RFZ 头部读/写 + magic 校验

重置相关已注释地址: 0xEA1AC1(满判定) / 0xEA1AD4(清表) / 0xEA1AF5(复位 next_code/code_bits) / 0xEA1C68(早切码宽增长) / 0xEA1C4E(正常加表) / 0xEA197D(稳态读码)。


7. 方法论 / 复核步骤

  1. server_health 确认加载 大四应用.exe (imagebase 0x400000)。
  2. decompile 0xEA1730 复核 fill;decompile 0xEA22C0 复核 init 常量。
  3. 对照 font/yb_lzw_full.py 定位差异: 该 py 缺重置、码宽增长晚一拍。
  4. decompile 0xEA20F0 确认头部仅消费 4 字节。
  5. IDA 重命名 4 个函数 + 关键局部变量, 并在 7 个关键地址写入算法注释, idb_save

7b. 实测验证 (2026-06-10)

测试脚本 temp/rfz_lzw_verify.py 按本规范(含 §2.6 重置 / §2.6b 哨兵 / §2.7 早切)解码 font/live_extracted/rfz_14pt_2461689.bin(2,461,689 B), 结果:

验证项 结果
LZW 流消费 2,461,685 / 2,461,685 字节 精确完整消费
非法码错误 0 (455 次字典重置全部干净)
输出大小 13,414,560 B (12.8 MB)
头部字符串 ruhuna::Database / RFO_SEGAKAKUGOTHIC_DB_14pt / yabukita / RHFONTDB / TextureResource / Glyph 全部正确
字符索引表 0x300000 处 0FFF 游程更正: 0x300000 在第一张纹理像素区内, 0FFF=ARGB4444 透明白; 文件无独立索引表 (见 §4.1)
内嵌纹理 2 张 DDS 图集 (2048×2048 + 2048×1024), ARGB4444 16bpp ✓ (见 §4)
与旧 buggy 输出 在 offset 0x24 即分叉(旧解码器 §2.6b 哨兵 bug 吞掉 0x00)
全字号验证 (2026-06-10) 6 个 .rfz 经 font/rfz_unpack.py 全部解包: 字形数/纹理张数与元数据自洽, LZW 零非法码

结论: 修正 §2.6 / §2.6b / §2.7 三点后, 离线解码器可对整个压缩流 100% 正确解包。 整条 2.46MB 流被精确消费且零非法码, 是 LZW 解码正确性的充分证据(错误解码会在数百码字内 撞上非法码或流错位)。输出保存为 font/live_extracted/lzw_fixed_output.bin

8. 结论

RFZ = [4B 头部] + [LZW 流]。LZW 是带早切码宽满表重置(阈值 4094, 重置后首码字不加表) 的标准 LZW 变体。掌握 §2.6/§2.7 这两点后即可离线 100% 正确解包, 此前独立解码器的"5000+ 分叉" 正是因为缺失这两点。

评论